/*
 *    This program is free software; you can redistribute it and/or modify
 *    it under the terms of the GNU General Public License as published by
 *    the Free Software Foundation; either version 2 of the License, or
 *    (at your option) any later version.
 *
 *    This program is distributed in the hope that it will be useful,
 *    but WITHOUT ANY WARRANTY; without even the implied warranty of
 *    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *    GNU General Public License for more details.
 *
 *    You should have received a copy of the GNU General Public License
 *    along with this program; if not, write to the Free Software
 *    Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
 */

/*
 * ClassDiscovery.java
 * Copyright (C) 2005 University of Waikato, Hamilton, New Zealand
 *
 */

package weka.core;

import java.io.File;
import java.lang.reflect.Modifier;
import java.net.URL;
import java.util.Collections;
import java.util.Comparator;
import java.util.Enumeration;
import java.util.HashSet;
import java.util.Hashtable;
import java.util.StringTokenizer;
import java.util.Vector;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;

/**
 * This class is used for discovering classes that implement a certain interface
 * or a derived from a certain class.
 * 
 * @author FracPete (fracpete at waikato dot ac dot nz)
 * @version $Revision: 5377 $
 * @see StringCompare
 */
public class ClassDiscovery implements RevisionHandler {

	/** whether to output some debug information. */
	public final static boolean VERBOSE = false;

	/**
	 * for caching queries (classname+packagename &lt;-&gt; Vector with
	 * classnames).
	 */
	protected static Hashtable<String, Vector> m_Cache;

	/** notify if VERBOSE is still on */
	static {
		if (VERBOSE)
			System.err.println(ClassDiscovery.class.getName() + ": VERBOSE ON");
	}

	/**
	 * Checks whether the "otherclass" is a subclass of the given "superclass".
	 * 
	 * @param superclass
	 *            the superclass to check against
	 * @param otherclass
	 *            this class is checked whether it is a subclass of the the
	 *            superclass
	 * @return TRUE if "otherclass" is a true subclass
	 */
	public static boolean isSubclass(String superclass, String otherclass) {
		try {
			return isSubclass(Class.forName(superclass),
					Class.forName(otherclass));
		} catch (Exception e) {
			return false;
		}
	}

	/**
	 * Checks whether the "otherclass" is a subclass of the given "superclass".
	 * 
	 * @param superclass
	 *            the superclass to check against
	 * @param otherclass
	 *            this class is checked whether it is a subclass of the the
	 *            superclass
	 * @return TRUE if "otherclass" is a true subclass
	 */
	public static boolean isSubclass(Class superclass, Class otherclass) {
		Class currentclass;
		boolean result;

		result = false;
		currentclass = otherclass;
		do {
			result = currentclass.equals(superclass);

			// topmost class reached?
			if (currentclass.equals(Object.class))
				break;

			if (!result)
				currentclass = currentclass.getSuperclass();
		} while (!result);

		return result;
	}

	/**
	 * Checks whether the given class implements the given interface.
	 * 
	 * @param intf
	 *            the interface to look for in the given class
	 * @param cls
	 *            the class to check for the interface
	 * @return TRUE if the class contains the interface
	 */
	public static boolean hasInterface(String intf, String cls) {
		try {
			return hasInterface(Class.forName(intf), Class.forName(cls));
		} catch (Exception e) {
			return false;
		}
	}

	/**
	 * Checks whether the given class implements the given interface.
	 * 
	 * @param intf
	 *            the interface to look for in the given class
	 * @param cls
	 *            the class to check for the interface
	 * @return TRUE if the class contains the interface
	 */
	public static boolean hasInterface(Class intf, Class cls) {
		Class[] intfs;
		int i;
		boolean result;
		Class currentclass;

		result = false;
		currentclass = cls;
		do {
			// check all the interfaces, this class implements
			intfs = currentclass.getInterfaces();
			for (i = 0; i < intfs.length; i++) {
				if (intfs[i].equals(intf)) {
					result = true;
					break;
				}
			}

			// get parent class
			if (!result) {
				currentclass = currentclass.getSuperclass();

				// topmost class reached or no superclass?
				if ((currentclass == null)
						|| (currentclass.equals(Object.class)))
					break;
			}
		} while (!result);

		return result;
	}

	/**
	 * If the given package can be found in this part of the classpath then an
	 * URL object is returned, otherwise <code>null</code>.
	 * 
	 * @param classpathPart
	 *            the part of the classpath to look for the package
	 * @param pkgname
	 *            the package to look for
	 * @return if found, the url as string, otherwise null
	 */
	protected static URL getURL(String classpathPart, String pkgname) {
		String urlStr;
		URL result;
		File classpathFile;
		File file;
		JarFile jarfile;
		Enumeration enm;
		String pkgnameTmp;

		result = null;
		urlStr = null;

		try {
			classpathFile = new File(classpathPart);

			// directory or jar?
			if (classpathFile.isDirectory()) {
				// does the package exist in this directory?
				file = new File(classpathPart + pkgname);
				if (file.exists())
					urlStr = "file:" + classpathPart + pkgname;
			} else {
				// is package actually included in jar?
				jarfile = new JarFile(classpathPart);
				enm = jarfile.entries();
				pkgnameTmp = pkgname.substring(1); // remove the leading "/"
				while (enm.hasMoreElements()) {
					if (enm.nextElement().toString().startsWith(pkgnameTmp)) {
						urlStr = "jar:file:" + classpathPart + "!" + pkgname;
						break;
					}
				}
			}
		} catch (Exception e) {
			// ignore
		}

		// try to generate URL from url string
		if (urlStr != null) {
			try {
				result = new URL(urlStr);
			} catch (Exception e) {
				System.err.println("Trying to create URL from '" + urlStr
						+ "' generates this exception:\n" + e);
				result = null;
			}
		}

		return result;
	}

	/**
	 * Checks the given packages for classes that inherited from the given
	 * class, in case it's a class, or implement this class, in case it's an
	 * interface.
	 * 
	 * @param classname
	 *            the class/interface to look for
	 * @param pkgnames
	 *            the packages to search in
	 * @return a list with all the found classnames
	 */
	public static Vector find(String classname, String[] pkgnames) {
		Vector result;
		Class cls;

		result = new Vector();

		try {
			cls = Class.forName(classname);
			result = find(cls, pkgnames);
		} catch (Exception e) {
			e.printStackTrace();
		}

		return result;
	}

	/**
	 * Checks the given package for classes that inherited from the given class,
	 * in case it's a class, or implement this class, in case it's an interface.
	 * 
	 * @param classname
	 *            the class/interface to look for
	 * @param pkgname
	 *            the package to search in
	 * @return a list with all the found classnames
	 */
	public static Vector find(String classname, String pkgname) {
		Vector result;
		Class cls;

		result = new Vector();

		try {
			cls = Class.forName(classname);
			result = find(cls, pkgname);
		} catch (Exception e) {
			e.printStackTrace();
		}

		return result;
	}

	/**
	 * Checks the given packages for classes that inherited from the given
	 * class, in case it's a class, or implement this class, in case it's an
	 * interface.
	 * 
	 * @param cls
	 *            the class/interface to look for
	 * @param pkgnames
	 *            the packages to search in
	 * @return a list with all the found classnames
	 */
	public static Vector find(Class cls, String[] pkgnames) {
		Vector result;
		int i;
		HashSet names;

		result = new Vector();

		names = new HashSet();
		for (i = 0; i < pkgnames.length; i++)
			names.addAll(find(cls, pkgnames[i]));

		// sort result
		result.addAll(names);
		Collections.sort(result, new StringCompare());

		return result;
	}

	/**
	 * Checks the given package for classes that inherited from the given class,
	 * in case it's a class, or implement this class, in case it's an interface.
	 * 
	 * @param cls
	 *            the class/interface to look for
	 * @param pkgname
	 *            the package to search in
	 * @return a list with all the found classnames
	 */
	public static Vector find(Class cls, String pkgname) {
		Vector result;
		StringTokenizer tok;
		String part;
		String pkgpath;
		File dir;
		File[] files;
		URL url;
		int i;
		Class clsNew;
		String classname;
		JarFile jar;
		JarEntry entry;
		Enumeration enm;

		// already cached?
		result = getCache(cls, pkgname);

		if (result == null) {
			result = new Vector();

			if (VERBOSE)
				System.out.println("Searching for '" + cls.getName() + "' in '"
						+ pkgname + "':");

			// turn package into path
			pkgpath = pkgname.replaceAll("\\.", "/");

			// check all parts of the classpath, to include additional classes
			// from
			// "parallel" directories/jars, not just the first occurence
			tok = new StringTokenizer(System.getProperty("java.class.path"),
					System.getProperty("path.separator"));

			while (tok.hasMoreTokens()) {
				part = tok.nextToken();
				if (VERBOSE)
					System.out.println("Classpath-part: " + part);

				// does package exist in this part of the classpath?
				url = getURL(part, "/" + pkgpath);
				if (VERBOSE) {
					if (url == null)
						System.out.println("   " + pkgpath + " NOT FOUND");
					else
						System.out.println("   " + pkgpath + " FOUND");
				}
				if (url == null)
					continue;

				// find classes
				dir = new File(part + "/" + pkgpath);
				if (dir.exists()) {
					files = dir.listFiles();
					for (i = 0; i < files.length; i++) {
						// only class files
						if ((!files[i].isFile())
								|| (!files[i].getName().endsWith(".class")))
							continue;

						try {
							classname = pkgname
									+ "."
									+ files[i].getName().replaceAll(".*/", "")
											.replaceAll("\\.class", "");
							result.add(classname);
						} catch (Exception e) {
							e.printStackTrace();
						}
					}
				} else {
					try {
						jar = new JarFile(part);
						enm = jar.entries();
						while (enm.hasMoreElements()) {
							entry = (JarEntry) enm.nextElement();

							// only class files
							if ((entry.isDirectory())
									|| (!entry.getName().endsWith(".class")))
								continue;

							classname = entry.getName().replaceAll("\\.class",
									"");

							// only classes in the particular package
							if (!classname.startsWith(pkgpath))
								continue;

							// no sub-package
							if (classname.substring(pkgpath.length() + 1)
									.indexOf("/") > -1)
								continue;

							result.add(classname.replaceAll("/", "."));
						}
					} catch (Exception e) {
						e.printStackTrace();
					}
				}
			}

			// check classes
			i = 0;
			while (i < result.size()) {
				try {
					clsNew = Class.forName((String) result.get(i));

					// no abstract classes
					if (Modifier.isAbstract(clsNew.getModifiers()))
						result.remove(i);
					// must implement interface
					else if ((cls.isInterface())
							&& (!hasInterface(cls, clsNew)))
						result.remove(i);
					// must be derived from class
					else if ((!cls.isInterface()) && (!isSubclass(cls, clsNew)))
						result.remove(i);
					else
						i++;
				} catch (Throwable e) {
					System.err.println("Checking class: " + result.get(i));
					e.printStackTrace();
					result.remove(i);
				}
			}

			// sort result
			Collections.sort(result, new StringCompare());

			// add to cache
			addCache(cls, pkgname, result);
		}

		return result;
	}

	/**
	 * adds all the sub-directories recursively to the list.
	 * 
	 * @param prefix
	 *            the path prefix
	 * @param dir
	 *            the directory to look in for sub-dirs
	 * @param list
	 *            the current list of sub-dirs
	 * @return the new list of sub-dirs
	 */
	protected static HashSet getSubDirectories(String prefix, File dir,
			HashSet list) {
		File[] files;
		int i;
		String newPrefix;

		// add directory to the list
		if (prefix == null)
			newPrefix = "";
		else if (prefix.length() == 0)
			newPrefix = dir.getName();
		else
			newPrefix = prefix + "." + dir.getName();

		if (newPrefix.length() != 0)
			list.add(newPrefix);

		// search for sub-directories
		files = dir.listFiles();
		if (files != null) {
			for (i = 0; i < files.length; i++) {
				if (files[i].isDirectory())
					list = getSubDirectories(newPrefix, files[i], list);
			}
		}

		return list;
	}

	/**
	 * Lists all packages it can find in the classpath.
	 * 
	 * @return a list with all the found packages
	 */
	public static Vector findPackages() {
		Vector result;
		StringTokenizer tok;
		String part;
		File file;
		JarFile jar;
		JarEntry entry;
		Enumeration enm;
		HashSet set;

		result = new Vector();
		set = new HashSet();

		// check all parts of the classpath, to include additional classes from
		// "parallel" directories/jars, not just the first occurence
		tok = new StringTokenizer(System.getProperty("java.class.path"),
				System.getProperty("path.separator"));

		while (tok.hasMoreTokens()) {
			part = tok.nextToken();
			if (VERBOSE)
				System.out.println("Classpath-part: " + part);

			// find classes
			file = new File(part);
			if (file.isDirectory()) {
				set = getSubDirectories(null, file, set);
			} else if (file.exists()) {
				try {
					jar = new JarFile(part);
					enm = jar.entries();
					while (enm.hasMoreElements()) {
						entry = (JarEntry) enm.nextElement();

						// only directories
						if (entry.isDirectory())
							set.add(entry.getName().replaceAll("/", ".")
									.replaceAll("\\.$", ""));
					}
				} catch (Exception e) {
					e.printStackTrace();
				}
			}
		}

		// sort result
		set.remove("META-INF");
		result.addAll(set);
		Collections.sort(result, new StringCompare());

		return result;
	}

	/**
	 * initializes the cache for the classnames.
	 */
	protected static void initCache() {
		if (m_Cache == null)
			m_Cache = new Hashtable<String, Vector>();
	}

	/**
	 * adds the list of classnames to the cache.
	 * 
	 * @param cls
	 *            the class to cache the classnames for
	 * @param pkgname
	 *            the package name the classes were found in
	 * @param classnames
	 *            the list of classnames to cache
	 */
	protected static void addCache(Class cls, String pkgname, Vector classnames) {
		initCache();
		m_Cache.put(cls.getName() + "-" + pkgname, classnames);
	}

	/**
	 * returns the list of classnames associated with this class and package, if
	 * available, otherwise null.
	 * 
	 * @param cls
	 *            the class to get the classnames for
	 * @param pkgname
	 *            the package name for the classes
	 * @return the classnames if found, otherwise null
	 */
	protected static Vector getCache(Class cls, String pkgname) {
		initCache();
		return m_Cache.get(cls.getName() + "-" + pkgname);
	}

	/**
	 * clears the cache for class/classnames relation.
	 */
	public static void clearCache() {
		initCache();
		m_Cache.clear();
	}

	/**
	 * Returns the revision string.
	 * 
	 * @return the revision
	 */
	public String getRevision() {
		return RevisionUtils.extract("$Revision: 5377 $");
	}

	/**
	 * Possible calls:
	 * <ul>
	 * <li>
	 * weka.core.ClassDiscovery &lt;packages&gt;<br/>
	 * Prints all the packages in the current classpath</li>
	 * <li>
	 * weka.core.ClassDiscovery &lt;classname&gt; &lt;packagename(s)&gt;<br/>
	 * Prints the classes it found.</li>
	 * </ul>
	 * 
	 * @param args
	 *            the commandline arguments
	 */
	public static void main(String[] args) {
		Vector list;
		Vector packages;
		int i;
		StringTokenizer tok;

		if ((args.length == 1) && (args[0].equals("packages"))) {
			list = findPackages();
			for (i = 0; i < list.size(); i++)
				System.out.println(list.get(i));
		} else if (args.length == 2) {
			// packages
			packages = new Vector();
			tok = new StringTokenizer(args[1], ",");
			while (tok.hasMoreTokens())
				packages.add(tok.nextToken());

			// search
			list = ClassDiscovery.find(args[0],
					(String[]) packages.toArray(new String[packages.size()]));

			// print result, if any
			System.out.println("Searching for '" + args[0] + "' in '" + args[1]
					+ "':\n" + "  " + list.size() + " found.");
			for (i = 0; i < list.size(); i++)
				System.out.println("  " + (i + 1) + ". " + list.get(i));
		} else {
			System.out.println("\nUsage:");
			System.out.println(ClassDiscovery.class.getName() + " packages");
			System.out.println("\tlists all packages in the classpath");
			System.out.println(ClassDiscovery.class.getName()
					+ " <classname> <packagename(s)>");
			System.out
					.println("\tlists classes derived from/implementing 'classname' that");
			System.out
					.println("\tcan be found in 'packagename(s)' (comma-separated list");
			System.out.println();
			System.exit(1);
		}
	}

	/**
	 * compares two strings. The following order is used:<br/>
	 * <ul>
	 * <li>case insensitive</li>
	 * <li>german umlauts (&auml; , &ouml; etc.) or other non-ASCII letters are
	 * treated as special chars</li>
	 * <li>special chars &lt; numbers &lt; letters</li>
	 * </ul>
	 */
	public static class StringCompare implements Comparator, RevisionHandler {

		/**
		 * appends blanks to the string if its shorter than <code>len</code>.
		 * 
		 * @param s
		 *            the string to pad
		 * @param len
		 *            the minimum length for the string to have
		 * @return the padded string
		 */
		private String fillUp(String s, int len) {
			while (s.length() < len)
				s += " ";
			return s;
		}

		/**
		 * returns the group of the character: 0=special char, 1=number,
		 * 2=letter.
		 * 
		 * @param c
		 *            the character to check
		 * @return the group
		 */
		private int charGroup(char c) {
			int result;

			result = 0;

			if ((c >= 'a') && (c <= 'z'))
				result = 2;
			else if ((c >= '0') && (c <= '9'))
				result = 1;

			return result;
		}

		/**
		 * Compares its two arguments for order.
		 * 
		 * @param o1
		 *            the first object
		 * @param o2
		 *            the second object
		 * @return -1 if o1&lt;o2, 0 if o1=o2 and 1 if o1&;gt;o2
		 */
		public int compare(Object o1, Object o2) {
			String s1;
			String s2;
			int i;
			int result;
			int v1;
			int v2;

			result = 0; // they're equal

			// get lower case string
			s1 = o1.toString().toLowerCase();
			s2 = o2.toString().toLowerCase();

			// same length
			s1 = fillUp(s1, s2.length());
			s2 = fillUp(s2, s1.length());

			for (i = 0; i < s1.length(); i++) {
				// same char?
				if (s1.charAt(i) == s2.charAt(i)) {
					result = 0;
				} else {
					v1 = charGroup(s1.charAt(i));
					v2 = charGroup(s2.charAt(i));

					// different type (special, number, letter)?
					if (v1 != v2) {
						if (v1 < v2)
							result = -1;
						else
							result = 1;
					} else {
						if (s1.charAt(i) < s2.charAt(i))
							result = -1;
						else
							result = 1;
					}

					break;
				}
			}

			return result;
		}

		/**
		 * Indicates whether some other object is "equal to" this Comparator.
		 * 
		 * @param obj
		 *            the object to compare with this Comparator
		 * @return true if the object is a StringCompare object as well
		 */
		public boolean equals(Object obj) {
			return (obj instanceof StringCompare);
		}

		/**
		 * Returns the revision string.
		 * 
		 * @return the revision
		 */
		public String getRevision() {
			return RevisionUtils.extract("$Revision: 5377 $");
		}
	}
}
